iT邦幫忙

2026 iThome 鐵人賽

DAY 28
0
AI Engineering

AI 的駕馭之道:一個 AI Code Reviewer 的養成、評測與邊界實錄系列 第 28

Day 28|收得掉,也長得回來才算平台:對帳迴圈與 40 分鐘全站卡死

  • 分享至 

  • xImage
  •  

收拾與重建兩半:左邊推車收走蓋了同一個戳記的容器,沒有戳記的那顆不動;中間停在 40 分鐘的時鐘與一條沒關的 fd,右邊把缺角的架子補回去

簡短回顧

昨天講這套東西刻意沒有的能力,以及那條「決定讓一個能力存在,就得為它付出結構」的判準。

今天講召喚出來的東西誰收。開一場很快,按一下就有;但東西開出來之後,總得有人負責它的下場。

場次結束誰收拾,決定了明天還開不開得起來。Day 16 講的是規則會退役,今天講的是容器會留下。兩件事同一個形狀:存在過的東西,誰負責讓它不在。

東西放在哪,才不會跟著容器一起消失?

先把地基講清楚,因為後面兩個事故都是從這裡長出來的。

這套系統的狀態住在三個地方,各自負責不同的事:

對話住在掛出去的目錄。 每個使用者有一個自己的狀態空間掛進容器(ADR 0014)。容器被殺掉、被重建、被換一個 image,對話都還在,因為那些東西根本不在容器裡面。這是整套設計裡最重要的一條線:容器是可拋棄的,對話不是。

Session 的真身是容器。 那個 PTY、正在跑的 CLI,以及尚未存檔的緩衝內容,都只存在於那個容器裡。它死了就是死了;這一版接受不替執行中的狀態做備份。已經落盤的對話紀錄由外部掛載接住,但沒存檔的內容、正在執行的行程與完整終端畫面,都不在恢復範圍內。

DB 管的是需要跨 worker 取得共識的那幾件事。 哪個 port 已被宣告、哪個 view 屬於哪一場、這一輪誰持有對帳租約,都寫進同一套 DB。Port 競爭靠 UNIQUE 約束,租約取得則放在一筆不可交錯的交易裡;因此在這幾個共享點上,多個 worker 不必各自依賴自己的記憶做判斷。

有一件事我要講清楚,免得誤導:DB 不是可拋棄的。 對話的持久化不依賴 DB,不代表 DB 可以丟。DB 裡裝著使用者的密碼雜湊、加密後的憑證,以及需要長期保留的 Session 歷史紀錄(ADR 0010)。丟掉 DB,對話還在,但這台機器上的帳號跟稽核鏈就斷了。

該停掉的沒有停掉

對帳這件事最直覺的一半,是收拾。

單人使用一樣會漏東西,而且漏得比想像中快:

  • 孤兒容器。 建立過程中途失敗,補償流程刪掉 DB 裡的 creating 列,已經建立的容器卻因清理失敗而留下。它不屬於任何人,沒有任何畫面看得到它,但它佔著記憶體跟一個 port
  • 死掉的 view。 有人開了終端然後關掉分頁,那個觀看用的行程沒有收乾淨
  • 殘留的租約。 對帳程序拿了 lease 然後自己掛掉,那張票沒有還回去,下一輪就沒有人敢動手

這一半聽起來不難,難的是「不難」讓人以為它安全。我在這一半上踩了兩次,兩次都是同一天。

用名字認東西,遲早會認錯

第一條規則講在前面:要辨識「哪些東西歸我管」,只能靠你自己明確打上去的標記,不能靠命名慣例。

名字看起來是個很自然的判斷依據。你的東西都叫 myapp- 開頭,所以掃到 myapp- 開頭而登錄表裡沒有的,就是該收的。這個判斷在你寫下它的那一天是對的。

問題是名字不是只有你在取。編排工具會拿專案名當前綴、框架會有自己的預設命名、別人裝的東西可能剛好撞上同一個字。這些都在你的判斷條件之外,而且它們變動的時候不會通知你。

我踩到的版本是這樣:孤兒容器原本用名稱前綴認,控制平面自己容器化之後,compose 拿專案名當前綴,於是基礎設施容器全部符合那個前綴,而它們當然不在 Session 的登錄表裡。第一輪對帳就把反向代理跟對帳程序自己刪掉了。

這件事的形狀不限於容器。用檔名前綴清 /tmp、用 log 前綴過濾、用命名規則決定誰要被自動處理,都是同一個判斷。它們共通的性質是:判斷依據由別人維護,而你在它上面做破壞性動作。

改用 label 之後,第一層邊界才變成結構性的:沒有管理標記的容器,對帳程序根本不會納入候選。這不代表一個 label 就足以判定「該刪」;測試容器就是下一個反例。

還有對稱的另一半值得一提:測試會建自己的容器,那些容器帶著一樣的 label、卻不在正式的 DB 裡,所以正式的對帳程序看到它們一樣會當孤兒收掉。解法同理,再多打一個標記讓它認得出來並跳過。只要辨識規則可能誤判,兩個方向都得分開檢查:不該收的會不會被收掉,該收的會不會被漏掉。

「還沒出現」跟「已經不見了」長得一模一樣

第二次比較細,但它是這一半真正的難處。

建立一場 Session 的順序是:先寫 DB 那一列,再去起容器。這個順序是刻意的(不然容器起來了卻沒有人記得它,就是上面那種孤兒)。但它製造了一個窗口:DB 有列、Docker 還沒有容器

那個窗口可以很長。走限制網路的 profile 時,防火牆一鎖網路就會切斷半截下載,所以 trivy 的漏洞資料庫必須在套規則之前先更新完。沒命中快取的話,那份 DB 解壓後大約 1 GB,實測整整 36 秒。

如果對帳程序在這段時間看到「有列、沒容器」就判定它不見了、把列刪掉,會發生兩件事:建立流程回頭找不到自己那一列,直接失敗;而剛剛好不容易起來的容器,變成沒人認領的孤兒,下一輪被收掉。

對帳本身每 30 秒執行一輪;真正的寬限期則有三個:已經搶到 port、但還沒寫入 pid 的 view 宣告保留 30 秒,孤兒容器保留 120 秒,卡在建立中的列保留 300 秒。

這些是我依當時環境留下的保守設定值。Trivy DB 未命中快取時,我曾量到一次冷啟動花了 36 秒,解壓後資料約 1 GB;300 秒不是由 36 秒精算出來的,而是另外留下足以涵蓋環境波動的餘裕。

對帳每 30 秒執行一輪;View 宣告、孤兒容器與建立中狀態分別保留 30、120、300 秒,36 秒是一次冷啟動觀察,不是第四個寬限期

View 宣告的 30 秒寬限期,還有一個配套要求:另一個 worker 等待這個 view 就緒的時間,必須明顯小於 30 秒。它們是同一個窗口的兩端,一個在等對方準備好、一個在判定對方已經死了。等待的那一側如果設得太久,會等到一半,對方的宣告就先被當成逾期回收掉。

這一半我做完的時候,以為對帳做完了。

那 40 分鐘,我什麼都點不開

然後有一次,整個列表停了將近四十分鐘。

症狀是這樣的:網頁上的 Session 列表轉圈轉不出來,所有操作都沒有反應。而我在終端裡打 docker ps它秒回,容器都在,看起來一切正常。

這個落差是整件事最誤導人的地方,而它有一個很機械的原因:docker ps 讀的是 Daemon 記憶體裡的清單,不需要碰到任何一顆容器。列表端點不一樣,它會真的去碰:當時它為了判斷「還沒就緒過」的那幾列到底起來了沒,會各打一次 docker logs

而當時有一顆容器卡在 removing 狀態。Docker 對單顆容器有一把鎖,那顆容器卡住的時候,針對它的 inspectlogstop 全部不回應。

接下來是算數的部分。docker-py 的預設 timeout 是 60 秒,而那支列表端點每 15 秒被前端輪詢一次。每一次輪詢都有一條執行緒進去,卡在那顆容器上等滿六十秒才回來。因此每 15 秒一次的輪詢,遇上最長 60 秒的等待,會同時留下約四個尚未結束的請求。

而控制平面跑的是 gunicorn --workers 1 --threads 8。也就是說,一個開著的列表頁,就可能占住這個執行緒池約一半的處理能力。

我當時看到的是整個後端沒有反應,但剩下的處理能力去了哪裡,我沒有留下足夠證據。所以這裡只講算得出來的那一半:這顆卡住的容器成了全站失去回應的重要放大因子,而終端裡的 docker ps 一直在跟我說沒事。

修法是三件事,而第一件是把整個形狀反過來:

  1. 列表路徑不准碰 Docker。 它只讀 DB
  2. 狀態由背景對帳程序寫進 DB。 對帳卡住是對帳自己的事,不會傳染給任何一個看畫面的人
  3. 畫面上寫清楚這個數字是幾點跟 Daemon 求證的。 每一列旁邊寫「N 分鐘前確認」,超過對帳週期的數倍就轉成警示色

第三件事的措辭我改過一次,而那次修改比機制本身更值得講。警示色的語意必須是「對帳可能卡住」,不是「你的 Session 有問題」。因為那個當下 Session 通常好好的,出問題的是我看它的那條路。把兩件事講混了,使用者會去重開一個沒有壞掉的東西。

而同一顆容器也讓對帳那一輪整輪失敗了,靠的是一個更小的細節:移除容器逾時丟出來的是 urllib3 的 ReadTimeout,而當時那條路徑只接 docker.errors.APIError逾時不是 API 錯誤,於是例外一路穿出去,被主迴圈接成「本輪失敗」,於是跟那顆容器毫無關係的歸檔、view 清理、租約清理全部跟著停擺。

我不是在整輪外面籠統接住 Exception,而是只隔離每一次針對單顆容器的 Docker I/O。NotFound 仍交回呼叫端判斷;其他例外會留下紀錄並回傳 STUCK,讓這一顆留到下輪重試,其他工作繼續。整輪共用的 containers.list() 若失敗,對帳仍會停止。

逐顆隔離這一層,讓單顆容器問不到時只留下該列異常,不再拖垮整輪對帳。

刪掉容易,長回來難

對帳不是定期清垃圾,而是每一輪把現實推回期望狀態。 收掉多出的東西,只是其中一個方向;補回缺少的東西,才要求系統知道正確世界應該長什麼樣。前面講完了收拾,現在講另一半。

先講清楚這個東西是什麼,因為它是這一節唯一的例子。

昨天講到,Session 容器裡的 git 不直接連 GitLab,中間隔著一顆 NGINX 反向代理。PAT 由控制平面解密後寫入代理容器的 NGINX 設定,轉送時再由代理補上,因此不會進入 Session 容器。這顆代理是每個設了 token 的使用者一顆,由系統替他建立與維護;使用者知道它存在,但不需要自己操作。

於是問題來了:它掛掉的時候,誰負責讓它回來?

一開始我的答案是「建立 Session 的時候檢查一下,不在就建」。這個答案能動,但它是錯的形狀,因為它把「這個東西該存在」綁在「有人剛好要開一場」上面。沒有人開場的那段時間,那顆代理不在,而系統不知道。

正確的形狀是把它當成期望狀態:對帳程序定期問一句「照設定,現在應該有哪些代理?」然後跟實際有的比對。少了就建,多了就收。這跟 Kubernetes 的 Deployment 是同一個形狀,只是小很多。這個形狀叫 reconciliation loop,K8s 那邊負責跑它的元件就叫 reconciler,我這裡叫它「對帳程序」。

這裡有一個對照:

刪東西要判斷「這個不該存在」;重建則要判斷「這個該存在、現在不在、而且我有足夠資訊把它做出來」。兩個方向都做,才是我這裡要的完整控制迴圈。

收拾那一半需要的資訊比較局部:先用管理 label 確認它屬於這套系統,再確認 DB 沒有人認領,而且已經超過寬限期,才可以收掉。重建那一半要求你能夠從設定重新推導出正確的世界,那是完全不同層級的要求。我自己第一版也只做了前面那半,「開場的時候檢查一下」就是那個形狀。它逼你把「怎麼建出一個正確的東西」寫成一個可以重複執行的函式,而不是散在建立流程裡的一段程式碼。

而在寫這一段的時候,我還撞到一個更硬的問題:關掉這個功能的時候會怎樣?

我原本寫成「功能開著才跑收斂」。這個寫法有一個很安靜的後果:部署者把設定拿掉之後,既有代理會繼續帶著憑證、佔著位址;除非另外手動清理,否則那條程式路徑不會再回頭處理它們。

所以改成收斂照跑,只是期望狀態變成空集合。關閉的意思是收乾淨,不是停止管理。

這條有一個前提,而且是這一整篇前半在講的東西:收斂本身得先是對的。 收斂有 bug 卻讓它繼續跑,那不是繼續管理,是繼續用壞掉的方式管理,而設定都已經拿掉了,不會再有人回頭看它一眼。

為什麼那顆代理是每人一顆,不是每場一顆

還有一個決定要交代,因為它看起來很浪費:一個人同時開五場,那五場共用同一顆代理;改成每場一顆,隔離度不是更好嗎?

答案是限流。

在我這份設定裡,每顆代理都有自己的限流共享記憶體區域,而且使用固定的 $server_name 作為 key。因此一顆代理就是一個獨立的計數桶;如果改成每場 Session 一顆,同一個人開 N 場,就會得到 N 個彼此獨立的桶。

這無法只靠調整單顆代理的參數維持每人的總量,因為 N 會隨著 Session 數量改變。當然也可以另外加入集中式限流,但那等於再增加一層負責彙總的元件。在目前這個設計裡,讓同一個人的 Session 共用一顆代理,是最直接讓計數單位對齊使用者的方法。

一個沒關的 fd,怎麼把單場故障升級成全站事故

最後一個故事,它讓我重新理解「傷害升級」這件事。

在我使用的 docker-py 7.2.0 裡,attach_socket() 回傳的是 socket.SocketIO。呼叫它的 close() 只關掉包裝物件、遞減 SocketIO 引用,不會在這條物件鏈上立刻關掉底層 fd;那個 fd 要等 docker-py 內部物件被 CPython 回收才消失。

一條沒被關掉、也沒有人在讀的 attach 連線會發生什麼事?Daemon 那一側繼續往裡面寫,寫到 socket 緩衝滿,然後 Daemon 就卡在那個寫入上。在我當時的環境裡,那個緩衝量到 208 KB。從那一刻起,那一場的輸出廣播整個凍住。

有趣的是它為什麼不是每次都發生。在我當時的環境裡,高輸出的 TUI 大約 100 秒就把緩衝填滿;低輸出的通常在垃圾回收把 fd 收掉之前就沒事了。這是一場垃圾回收跟緩衝區的賽跑,而賽跑的結果決定你今天有沒有踩到 bug。這也是為什麼它很難重現。

我要先講清楚範圍:凍住的是一場 Session,不是整個 Daemon。 這一點很重要,因為接下來才是真正的故事。

傷害是怎麼升級的。 我看到那一場沒反應,於是做了任何人都會做的事:docker rm -f 把它砍掉。

rm 也卡住了,因為它要動的正是那顆卡住的容器。卡住的 rm 抱走了那顆容器的鎖,從此針對它的 inspectstoprm 全部掛住。然後 docker-py 高階 API 的 containers.list() 也開始逾時,因為它在預設的 sparse=False 下會逐顆 inspect。

到這一步,才是全站災難。而觸發它的不是那個 fd,是我為了解決那個 fd 而下的指令。這時安全的做法是先別再對那顆容器下命令,避免繼續放大問題。要讓環境恢復,我當時只能重啟 Docker daemon;在 macOS 上就是重新啟動 Docker Desktop。

診斷的部分也值得記一筆,而這一段不是我查出來的,是我把現象交給 Claude Code 協助追查。我一開始在 Host 上掃,怎麼都找不到那條沒關的連線;把檢查位置換到控制容器裡才看見。問題不是連線不存在,而是一開始看錯了位置。

修法是自己關掉底層 fd,不依賴垃圾回收。而關掉它還有一個附帶好處:Daemon 那側會立刻收到 EPIPE,它自己的清理路徑就會把對應的緩衝關掉,不需要等任何人。

回歸測試那邊我釘的是「fd 真的關了」,不是「close() 被呼叫了」。而且第一條斷言是先驗證前提:只關包裝物件的時候,那個 fd 仍然是活的。前提如果不成立,後面那條測試就沒有意義了,它會在一個根本不存在的問題上一直綠燈。

最後一個巧合值得記:對帳程序在這場災難裡活了下來,靠的正是上一節那個「接 Exception 不接 APIError」的決定。它是為了四十分鐘那次寫的,卻在這次擋下了連坐。

本日小結

今天講的是對帳迴圈的兩半:

  1. 狀態分三層。 對話住在掛出去的目錄、Session 的真身是容器、DB 是仲裁者。容器可拋棄,對話不可以,DB 也不可以
  2. 收拾只是一半,而且它自己也會出事。 辨識「哪些歸我管」只能靠自己打上去的標記,不能靠命名慣例。名字不是只有你在取,而你在它上面做的是破壞性動作。另外「還沒出現」跟「已經不見了」在資料上長得一模一樣,所以每一種收拾都得配一個有理由的寬限期
  3. docker ps 秒回不代表沒事。 它讀的是 Daemon 記憶體,inspect 才會碰到那顆容器。列表路徑從此不准碰 Docker
  4. 完整控制迴圈要能收也能補。 刪東西要判斷「這個不該存在」;重建則要判斷「這個該存在、現在不在、而且我有足夠資訊把它做出來」
  5. 關閉一個功能的意思是收乾淨,不是停止管理。 停止管理會留下一堆沒有人再看一眼的東西
  6. 限流的計數單位要跟治理對象對齊。 在我這份設定裡,每顆代理都有自己的計數桶,因此讓同一個人的 Session 共用一顆代理,才讓桶的單位對齊使用者
  7. 傷害升級的順序,往往比第一個 bug 更值得記。 那個沒關的 fd 只凍住一場;讓它變成全站災難的,是我為了修它而下的那道指令

明天講一件貫穿這二十九天的事:綠燈到底證明了什麼。


上一篇
Day 27|把「不做」變成結構:我刻意沒有做的那些
下一篇
Day 29|綠燈到底證明了什麼:假綠燈五形狀
系列文
AI 的駕馭之道:一個 AI Code Reviewer 的養成、評測與邊界實錄30
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言